﻿/*
 * Copyright 2012 Nascent Digital (http://www.nascentdigital.com)
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0 
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
using Nascent.GoogleAnalytics.Metrics;
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Windows.ApplicationModel;


namespace Nascent.GoogleAnalytics
{
    /// <summary>
    /// The core component responsible for collecting and submitting metrics
    /// to the Google Analytics service.
    /// </summary>
    internal sealed class AnalyticsService
    {
        #region constants

        private const string TrackingCodeVersion = "4.8nd";
        private const int RetryDelayMs = 1000;

        #endregion


        #region class variables

        private static readonly Random _randomizer;

        #endregion


        #region instance variables

        private readonly object _syncRoot;
        private readonly BlockingCollection<Metric> _metrics;
        private readonly AnalyticsSession _session;
        private int _retainCount;

        private Task _dispatcherTask;
        private CancellationTokenSource _dispatcherTokenSource;

        #endregion


        #region constructors

        static AnalyticsService()
        {
            // initialize class variables
            _randomizer = new Random();
        }

        public AnalyticsService(string webPropertyId)
        {
            // initialize instance variables
            _syncRoot = new object();
            _metrics = new BlockingCollection<Metric>();
            _session = AnalyticsSession.Load(webPropertyId);
            _retainCount = 0;
        }

        #endregion


        #region properties

        public string WebPropertyId
        {
            get { return _session.WebPropertyId; }
        }

        public bool IsRunning
        {
            get { return _dispatcherTask != null; }
        }

        #endregion


        #region public methods

        public void Retain()
        {
            // increment counter and begin if started
            lock (_syncRoot)
            {
                if (++_retainCount == 1)
                {
                    Start();
                }
            }
        }

        public void Release()
        {
            // decrement counter and end if stopped
            lock (_syncRoot)
            {
                if (--_retainCount == 0)
                {
                    Stop().Wait();
                }
            }
        }

        public void SetVariable(Variable variable)
        {
            // ignore if variable already exists
            Variable previousVariable;
            if (_session.Variables.TryGetValue(variable.Index,
                    out previousVariable)
                && previousVariable.Name == variable.Name
                && previousVariable.Value == variable.Value
                && previousVariable.Scope == variable.Scope)
            {
                return;
            }

            // update variable
            _session.Variables[variable.Index] = variable;
            variable.Reported = false;

            // persist change if required
            if (variable.Scope != VariableScope.Page
                || (previousVariable != null
                    && previousVariable.Scope != VariableScope.Page))
            {
                _session.UpdateVariables();
            }
        }

        public void ClearVariable(int index)
        {
            // remove variable and persist (if changed)
            Variable previousVariable;
            if (_session.Variables.TryGetValue(index,
                out previousVariable))
            {
                // remove variable
                _session.Variables.Remove(index);

                // persist change (if required)
                if (previousVariable.Scope != VariableScope.Page)
                {
                    _session.UpdateVariables();
                }
            }
        }

        public void RecordMetric(Metric metric)
        {
            // merge variables and mark submission time
            metric.Variables = TransferVariables();
            metric.Timestamp = DateTime.UtcNow;

            // TODO: push metric to serialized list

            // add metric to queue
            _metrics.Add(metric);
        }

        #endregion


        #region helper methods


        #region dispatching

        public void Start()
        {
            // assert not running
            Debug.Assert(_dispatcherTask == null,
                "Expected dispatcher to be started only once.");

            // create dispatcher token
            _dispatcherTokenSource = new CancellationTokenSource();

            // start run method asynchronously
            _dispatcherTask = Task.Factory.StartNew(OnRun,
                _dispatcherTokenSource.Token, TaskCreationOptions.LongRunning,
                TaskScheduler.Default);
        }

        public async Task Stop()
        {
            // assert running
            Debug.Assert(_dispatcherTask != null,
                "Expected dispatcher to be stopped only when running.");

            // cancel task
            _dispatcherTokenSource.Cancel();
            _dispatcherTokenSource = null;

            // await for task to complete
            try
            {
                await _dispatcherTask;
            }
            catch (OperationCanceledException)
            {
            }

            // ensure dispatcher is cleared
            finally
            {
                _dispatcherTask = null;
            }
        }

        private void OnRun()
        {
            // setup web client for reuse
            HttpClient httpClient = new HttpClient();
            httpClient.DefaultRequestHeaders
                .Add("User-Agent", "Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.2; Trident/6.0)");

            // start run loop
            CancellationToken cancellationToken = _dispatcherTokenSource.Token;
            while (true)
            {
                // stop if cancelled
                if (cancellationToken.IsCancellationRequested)
                {
                    cancellationToken.ThrowIfCancellationRequested();
                }

                // dequeue next items in queue and determine URI
                Metric metric = _metrics.Take(cancellationToken);

                // update session (may increment)
                _session.Update(metric.Timestamp);

                // get uri
                Uri metricSendUri = GetMetricUri(metric);

                // loop until metric is sent
                bool? metricSent = null;
                do
                {
                    // force delay if retrying send
                    if (metricSent.HasValue)
                    {
                        Task.Delay(RetryDelayMs, cancellationToken)
                            .Wait();
                    }

                    // start send request (blocks until response is complete)
                    Task<HttpResponseMessage> metricSendTask =
                        httpClient.GetAsync(metricSendUri,
                        cancellationToken);
                    metricSendTask.Wait();

                    // verify result
                    HttpResponseMessage metricSendResponse =
                        metricSendTask.Result;
                    metricSent = metricSendResponse.IsSuccessStatusCode;

                    // consume response if sent (ensure gif is downloaded)
                    if (metricSent == true)
                    {
                        Debug.WriteLine("analytic sent: " + metricSendUri.AbsoluteUri);

                        // get content
                        Task<byte[]> getContentTask = metricSendResponse.Content
                            .ReadAsByteArrayAsync();
                        getContentTask.Wait();

                        Debug.WriteLine("downloaded tracking image with {0} bytes",
                            getContentTask.Result.Length);
                    }

                } while (metricSent == false);
            }
        }

        #endregion

        #region variable management

        private Variable[] TransferVariables()
        {
            // get valid variables
            Variable[] variables = _session.Variables.Values
                .Where(v => v.Reported == false)
                .ToArray();

            // transfer variables if any
            if (variables.Length > 0)
            {
                // remove transient variables
                bool updateRequired = false;
                foreach (Variable variable in variables)
                {
                    // prune if page based
                    if (variable.Scope == VariableScope.Page)
                    {
                        _session.Variables.Remove(variable.Index);
                    }

                    // or mark variable reported
                    else
                    {
                        variable.Reported = true;
                        updateRequired = true;
                    }
                }

                // update persist variables (if required)
                if (updateRequired)
                {
                    _session.UpdateVariables();
                }

                // return variables
                return variables;
            }

            // or return null
            else
            {
                return null;
            }
        }

        #endregion

        #region tracking code generation

        private Uri GetMetricUri(Metric metric)
        {
            // create uri builder
            StringBuilder url = new StringBuilder(1024);
            url.Append("http://www.google-analytics.com/__utm.gif?");

            // add core metric params
            AppendCoreParams(url, metric);

            // add metric-specifc params
            if (metric is PageView)
            {
                AppendPageViewParams(url, (PageView)metric);
            }
            else if (metric is PageEvent)
            {
                AppendPageEventParams(url, (PageEvent)metric);
            }
            else
            {
                throw new NotImplementedException(string.Format(
                    CultureInfo.InvariantCulture,
                    "Persistence of metric {0} is not implemented.",
                    metric.GetType().Name));
            }

            // return uri
            Uri metricUri = new Uri(url.ToString(), UriKind.Absolute);
            return metricUri;
        }

        private void AppendCoreParams(StringBuilder url, Metric metric)
        {
            // add account information
            url.Append("utmac=");
            url.Append(WebPropertyId);

            // add cookie information (i.e. session information)
            AppendCookieString(url, metric);

            // add version information
            url.Append("&utmwv=");
            url.Append(TrackingCodeVersion);
            url.Append("&utmn=");
            url.Append(Math.Abs(DateTime.UtcNow.GetHashCode()));
            url.Append("&utmje=0");
            url.Append("&utmfl=-");

            // add content information
            url.Append("&utmhn=localhost");
            url.Append("&utmr=-");
            url.Append("&utmp=");
            url.Append(Uri.EscapeUriString(metric.Page));
            url.Append("&utmdt=");
            url.Append(Uri.EscapeUriString(Package.Current.Id.Name));
            url.Append("&utmcs=UTF-8");

            // add display information
            url.Append("&utmsr=-");
            url.Append("&utmvp=-");
            url.Append("&utmsc=-");

            // add locale information
            url.Append("&utmul=");
            url.Append(CultureInfo.CurrentUICulture.Name.ToLowerInvariant());
        }

        private void AppendCookieString(StringBuilder url, Metric metric)
        {
            // create session information       
            string visitorInformation = string.Format(
                CultureInfo.InvariantCulture, "1.{0}.{1}.{2}.{3}.{4}",
                _session.VisitorId, _session.FirstStartTime, _session.LastStartTime,
                _session.CurrentStartTime, _session.Count);

            // create referral information
            string referralInformation = string.Format(
                CultureInfo.InvariantCulture, 
                "1.{0}.1.1.utmcsr=(direct)|utmccn=(organic)|utmcmd=(none)",
                metric.Timestamp.ToUnixTimestamp());
            // append cookie
            url.Append("&utmcc=");
            url.Append(Uri.EscapeDataString(string.Format(
                "__utma={0};+__utmz={1};", visitorInformation, 
                referralInformation)));
        }

        private void AppendPageViewParams(StringBuilder url, PageView metric)
        {
            // record variables (if any)
            if (metric.Variables != null)
            {
                url.Append("&utme=");
                AppendCustomVariables(url, metric);
            }
        }

        private void AppendPageEventParams(StringBuilder url, PageEvent metric)
        {
            url.Append("&utmt=event");

            // submit event with value (if specified)
            url.Append("&utme=");
            if (metric.Value.HasValue)
            {
                url.Append(string.Format(
                    CultureInfo.InvariantCulture, "5({0}*{1}*{2})({3})",
                    EncodeExtensibleParam(metric.Category),
                    EncodeExtensibleParam(metric.Action),
                    EncodeExtensibleParam(metric.Label),
                    metric.Value));
            }

            // or drop value
            else
            {
                url.Append(string.Format(
                    CultureInfo.InvariantCulture, "5({0}*{1}*{2})",
                    EncodeExtensibleParam(metric.Category),
                    EncodeExtensibleParam(metric.Action),
                    EncodeExtensibleParam(metric.Label)));
            }

            // record variables
            AppendCustomVariables(url, metric);
        }

        private void AppendCustomVariables(StringBuilder url, Metric metric)
        {
            // skip if no variables are defined
            Variable[] variables = metric.Variables;
            if (variables == null)
            {
                return;
            }

            // append variable labels
            bool isFirstVariable = true;
            url.Append("8(");
            foreach (Variable variable in variables)
            {
                // add separator (if required)
                if (isFirstVariable == false)
                {
                    url.Append('*');
                }
                else
                {
                    isFirstVariable = false;
                }

                // add variable component
                url.Append(variable.Index);
                url.Append('!');
                url.Append(EncodeExtensibleParam(variable.Name));
            }

            // append variable values
            isFirstVariable = true;
            url.Append(")9(");
            foreach (Variable variable in variables)
            {
                // add separator (if required)
                if (isFirstVariable == false)
                {
                    url.Append('*');
                }
                else
                {
                    isFirstVariable = false;
                }

                // add variable component
                url.Append(variable.Index);
                url.Append('!');
                url.Append(EncodeExtensibleParam(variable.Value));
            }

            // append variable scopes
            if (variables.Count(v => v.Scope != VariableScope.Page) > 0)
            {
                isFirstVariable = true;
                url.Append(")11(");
                foreach (Variable variable in variables)
                {
                    // skip if page-scoped
                    if (variable.Scope == VariableScope.Page)
                    {
                        continue;
                    }

                    // add separator (if required)
                    if (isFirstVariable == false)
                    {
                        url.Append('*');
                    }
                    else
                    {
                        isFirstVariable = false;
                    }

                    // add variable component
                    url.Append(variable.Index);
                    url.Append('!');
                    url.Append((int)variable.Scope);
                }
            }
            url.Append(')');
        }

        private static string EncodeExtensibleParam(string value)
        {
            // ignore empty values
            if (string.IsNullOrEmpty(value))
            {
                return string.Empty;
            }

            // replace characters with equivalent
            value = value.Replace("'", "'0");
            value = value.Replace(")", "'1");
            value = value.Replace("*", "'2");
            value = value.Replace("!", "'3");

            // URI-escape value
            value = Uri.EscapeUriString(value);

            // return result
            return value;
        }

        #endregion


        #endregion

    }  // class AnalyticsService

}  // namespace Nascent.GoogleAnalytics
